Mene perinteisiä esimerkkiperustaisia testejä pidemmälle. Tämä kattava opas tutkii ominaisuusperustaista testausta JavaScriptissä käyttäen fast-checkia, mikä auttaa sinua löytämään enemmän bugeja vähemmällä koodilla.
Esimerkkien tuolla puolen: Syvä sukellus ominaisuusperustaiseen testaukseen JavaScriptissä
Ohjelmistokehittäjinä käytämme huomattavan paljon aikaa testien kirjoittamiseen. Laadimme huolellisesti yksikkötestejä, integraatiotestejä ja päästä päähän -testejä varmistaaksemme, että sovelluksemme ovat vankkoja, luotettavia ja vapaita regressioista. Tässä hallitseva paradigma on esimerkkiperustainen testaus. Ajattelemme tiettyä syötettä ja vahvistamme tietyn tulosteen. Syöte `[1, 2, 3]` pitäisi tuottaa tulosteen `6`. Syötteen `"hello"` pitäisi muuttua muotoon `"HELLO"`. Mutta tällä lähestymistavalla on hiljainen, piilevä heikkous: oma mielikuvituksemme.
Entä jos unohdat testata tyhjällä taulukolla? Negatiivisella luvulla? Unicode-merkkejä sisältävällä merkkijonolla? Syvästi sisäkkäisellä objektilla? Jokainen huomaamatta jäänyt reunatapaus on mahdollinen bugi, joka odottaa tapahtumistaan. Tässä kohtaa omaisuusperustainen testaus (Property-Based Testing, PBT) astuu näyttämölle tarjoten tehokkaan paradigman muutoksen, joka auttaa meitä rakentamaan luottavaisempaa ja joustavampaa ohjelmistoa.
Tämä kattava opas johdattaa sinut ominaisuusperustaisen testauksen maailmaan JavaScriptissä. Tutkimme, mitä se on, miksi se on niin tehokasta ja miten voit toteuttaa sen projekteissasi jo tänään käyttämällä suosittua `fast-check` -kirjastoa.
Perinteisen esimerkkiperustaisen testauksen rajoitukset
Otetaan huomioon yksinkertainen funktio, joka lajittelee numeroita sisältävän taulukon. Käyttämällä suosittua kehystä, kuten Jest tai Vitest, testimme saattaa näyttää tältä:
// Yksinkertainen (ja hieman naiivi) lajittelufunktio
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
// Tyypillinen esimerkkiperustainen testi
test('sortNumbers should correctly sort a simple array', () => {
const inputArray = [3, 1, 4, 1, 5, 9];
const expectedArray = [1, 1, 3, 4, 5, 9];
expect(sortNumbers(inputArray)).toEqual(expectedArray);
});
Tämä testi läpäisee. Voisimme lisätä muutaman `it` tai `test` -lohkon:
- Taulukko, joka on jo lajiteltu.
- Taulukko, jossa on negatiivisia lukuja.
- Taulukko, jossa on nolla.
- Tyhjä taulukko.
- Taulukko, jossa on päällekkäisiä numeroita (jonka olemme jo käsitelleet).
Meistä tuntuu hyvältä. Olemme käsitelleet perusasiat. Mutta mitä olemme jättäneet huomiotta? Entä `[-0, 0]`? Entä `[Infinity, -Infinity]`? Entä hyvin suuri taulukko, joka saattaa osua suorituskyvyn rajoihin tai outoihin JavaScript-moottorin optimointeihin? Perusongelma on se, että valitsemme tiedot manuaalisesti. Testimme ovat vain niin hyviä kuin keksimämme esimerkit, ja ihmiset ovat tunnetusti huonoja kuvittelemaan kaikkia niitä outoja ja ihmeellisiä tapoja, joilla data voidaan rakentaa.
Esimerkkiperustainen testaus vahvistaa, että koodisi toimii muutamissa käsin valituissa skenaarioissa. Ominaisuusperustainen testaus vahvistaa, että koodisi toimii kokonaisille syöteluokille.
Mitä on ominaisuusperustainen testaus? Paradigman muutos
Ominaisuusperustainen testaus kääntää käsikirjoituksen. Sen sijaan, että vahvistat, että tietty syöte tuottaa tietyn tulosteen, määrittelet koodillesi yleisen ominaisuuden, jonka pitäisi pitää paikkansa mille tahansa kelvolliselle syötteelle. Testauskehys generoi sitten satoja tai tuhansia satunnaisia syötteitä yrittääkseen todistaa ominaisuutesi vääräksi.
"Ominaisuus" on invariantti – korkean tason sääntö funktiosi käyttäytymisestä. `sortNumbers`-funktiomme ominaisuuksia voisivat olla:
- Idempotenssi: Jo lajitellun taulukon lajittelun ei pitäisi muuttaa sitä. `sortNumbers(sortNumbers(arr))` pitäisi olla sama kuin `sortNumbers(arr)`.
- Pituuden invarianttius: Lajitellun taulukon pitäisi olla yhtä pitkä kuin alkuperäisen taulukon.
- Sisällön invarianttius: Lajitellun taulukon pitäisi sisältää täsmälleen samat elementit kuin alkuperäisen taulukon, vain eri järjestyksessä.
- Järjestys: Lajitellun taulukon kahdelle vierekkäiselle elementille pätee `sorted[i] <= sorted[i+1]`.
Tämä lähestymistapa siirtää sinut ajattelemasta yksittäisiä esimerkkejä koodisi perussopimuksen ajatteluun. Tämä ajattelutavan muutos on uskomattoman arvokasta parempien ja ennustettavampien API:en suunnittelussa.
PBT:n ydin komponentit
Ominaisuusperustaisessa testauskehyksessä on tyypillisesti kaksi avainkomponenttia:
- Generaattorit (tai Arbitraries): Nämä ovat vastuussa laajan valikoiman satunnaisen datan tuottamisesta määritettyjen tyyppien (kokonaisluvut, merkkijonot, objektien taulukot jne.) mukaan. Ne ovat tarpeeksi älykkäitä tuottamaan paitsi "onnellisen polun" dataa, myös hankalia reunatapauksia, kuten tyhjiä merkkijonoja, `NaN`, `Infinity` ja paljon muuta.
- Shrinking: Tämä on taika-ainesosa. Kun kehys löytää syötteen, joka väärentää ominaisuutesi (eli aiheuttaa testin epäonnistumisen), se ei vain raportoi suurta, satunnaista syötettä. Sen sijaan se yrittää systemaattisesti löytää pienimmän ja yksinkertaisimman syötteen, joka edelleen aiheuttaa epäonnistumisen. Tämä tekee virheenkorjauksesta eksponentiaalisesti helpompaa.
Aloittaminen: PBT:n toteuttaminen `fast-check`:in avulla
Vaikka JavaScript-ekosysteemissä on useita PBT-kirjastoja, `fast-check` on kypsä, tehokas ja hyvin ylläpidetty valinta. Se integroituu saumattomasti suosittuihin testauskehyksiin, kuten Jest, Vitest, Mocha ja Jasmine.
Asennus ja asetus
Lisää ensin `fast-check` projektisi kehitysriippuvuuksiin. Oletetaan, että käytät testiajoa, kuten Jest.
npm install --save-dev fast-check jest
# tai
yarn add --dev fast-check jest
# tai
pnpm add -D fast-check jest
Ensimmäinen ominaisuusperustainen testi
Kirjoitetaan `sortNumbers`-testimme uudelleen käyttäen `fast-check`:ia. Testamme aiemmin määriteltyä "järjestys"-ominaisuutta: jokaisen elementin pitäisi olla pienempi tai yhtä suuri kuin sen jälkeinen.
import * as fc from 'fast-check';
// Sama funktio kuin ennen
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
test('the output of sortNumbers should be a sorted array', () => {
// 1. Kuvaile ominaisuus
fc.assert(
// 2. Määrittele arbitraries (syötteiden generaattorit)
fc.property(fc.array(fc.integer()), (data) => {
// `data` on satunnaisesti generoitu kokonaislukutaulukko
const sorted = sortNumbers(data);
// 3. Määrittele predikaatti (tarkistettava ominaisuus)
for (let i = 0; i < sorted.length - 1; ++i) {
if (sorted[i] > sorted[i + 1]) {
return false; // Ominaisuus vääristyy
}
}
return true; // Ominaisuus pätee tälle syötteelle
})
);
});
test('sorting should not change the array length', () => {
fc.assert(
fc.property(fc.array(fc.float()), (data) => {
const sorted = sortNumbers(data);
return sorted.length === data.length;
})
);
});
Pilkotetaan tämä osiin:
- `fc.assert()`: Tämä on suorittaja. Se suorittaa ominaisuustarkistuksesi monta kertaa (oletuksena 100).
- `fc.property()`: Tämä määrittelee itse ominaisuuden. Se ottaa yhden tai useamman arbitraryn argumenteiksi, jota seuraa predikaattifunktio.
- `fc.array(fc.integer())`: Tämä on arbitrarymme. Se kertoo `fast-check`:ille, että se generoi kokonaislukutaulukon (`fc.array`) (`fc.integer()`). `fast-check` generoi automaattisesti taulukoita, joilla on eri pituudet, eri kokonaislukuarvot (positiiviset, negatiiviset, nolla jne.).
- Predikaatti: Anonyymi funktio `(data) => { ... }` on paikka, jossa logiikkamme elää. Se vastaanottaa satunnaisesti generoidun datan ja sen on palautettava `true`, jos ominaisuus pätee, tai `false`, jos sitä rikotaan. `fast-check` tukee myös predikaattifunktioita, jotka heittävät virheen epäonnistuessaan, mikä integroituu hienosti Jestin `expect`-vahvistuksiin.
Nyt sen sijaan, että meillä olisi yksi testi yhdellä käsin valitulla taulukolla, meillä on testi, joka tarkistaa lajittelulogiikkamme 100 eri, automaattisesti generoitua taulukkoa vasten joka kerta, kun suoritamme testimme. Olemme lisänneet testikattavuuttamme massiivisesti vain muutamalla koodirivillä.
Arbitraryjen tutkiminen: Oikean datan generointi
PBT:n teho piilee sen kyvyssä generoida monipuolista ja haastavaa dataa. `fast-check` tarjoaa rikkaan valikoiman arbitraryjä kattaakseen lähes minkä tahansa datarakenteen, jonka voit kuvitella.
Perus Arbitraryt
Nämä ovat datan generoinnin rakennuspalikoita.
- `fc.integer()`, `fc.float()`, `fc.bigInt()`: Numeroille. Niitä voidaan rajoittaa, esim. `fc.integer({ min: 0, max: 100 })`.
- `fc.string()`, `fc.asciiString()`, `fc.unicodeString()`: Eri merkkijoukkojen merkkijonoille.
- `fc.boolean()`: Arvolle `true` tai `false`.
- `fc.constant(value)`: Palauttaa aina saman arvon. Hyödyllinen sekoitettuna `fc.oneof`- kanssa.
- `fc.constantFrom(val1, val2, ...)`: Palauttaa yhden annetuista vakioarvoista.
Monimutkaiset ja yhdistetyt Arbitraryt
Voit yhdistää perus arbitraryjä luodaksesi monimutkaisia datarakenteita.
- `fc.array(arbitrary, constraints)`: Generoi taulukon, jossa on annetun arbitraryn luomia elementtejä. Voit rajoittaa `minLength` ja `maxLength`.
- `fc.tuple(arb1, arb2, ...)`: Generoi kiinteän pituisen taulukon, jossa jokaisella elementillä on tietty, eri tyyppi.
- `fc.object(shape)`: Generoi objekteja, joilla on määritelty rakenne. Esimerkki: `fc.object({ id: fc.uuidV(4), name: fc.string() })`.
- `fc.oneof(arb1, arb2, ...)`: Generoi arvon mistä tahansa annetusta arbitrarystä. Tämä on erinomainen funktioiden testaamiseen, jotka käsittelevät useita datatyyppejä (esim. `string | number`).
- `fc.record({ key: arb, value: arb })`: Generoi objekteja, joita käytetään sanakirjoina tai karttoina, joissa avaimet ja arvot generoidaan arbitraryistä.
Omien Arbitraryjen luominen `map` ja `chain`-menetelmillä
Joskus tarvitset dataa, joka ei sovi vakiomuotoon. `fast-check` mahdollistaa omien arbitraryjen luomisen muuntamalla olemassa olevia.
Käyttämällä `.map()`
`.map()` -menetelmä muuntaa arbitraryn tulosteen joksikin muuksi. Luodaan esimerkiksi arbitrary, joka generoi ei-tyhjiä merkkijonoja.
const nonEmptyStringArb = fc.string({ minLength: 1 });
// Tai, muuntamalla merkkijono arrayn
const nonAStringArb = fc.array(fc.char().filter(c => c !== 'a'))
.map(chars => chars.join(''));
Käyttämällä `.chain()`
`.chain()` -menetelmä on tehokkaampi. Sen avulla voit luoda uuden arbitraryn edellisen generoidun arvon perusteella. Tämä on olennaista korreloituneen datan luomisessa.
Kuvittele, että sinun on generoitava taulukko ja sitten kelvollinen indeksi samaan taulukkoon. Et voi tehdä tätä kahdella erillisellä arbitraryllä, koska indeksi saattaa olla rajojen ulkopuolella. `.chain()` ratkaisee tämän täydellisesti.
// Generoi taulukko ja kelvollinen indeksi siihen
const arrayAndValidIndexArb = fc.array(fc.anything()).chain(arr => {
// Luo generoidun taulukon `arr` perusteella uusi arbitrary indeksille
const indexArb = fc.integer({ min: 0, max: arr.length - 1 });
// Palauta tuple taulukosta ja generoidusta indeksistä
return fc.tuple(fc.constant(arr), indexArb);
});
// Käyttö testissä
test('slicing at a valid index should work', () => {
fc.assert(
fc.property(arrayAndValidIndexArb, ([arr, index]) => {
// Sekä `arr` että `index` ovat taatusti yhteensopivia
const sliced = arr.slice(0, index);
expect(sliced.length).toBe(index);
})
);
});
Shrinkingin voima: Virheenkorjaus on helppoa
Ominaisuusperustaisen testauksen vakuuttavin ominaisuus on shrinking. Nähdäksesi sen toiminnassa, luodaan tarkoituksella bugillinen funktio.
// Tämä funktio epäonnistuu, jos syötetaulukko sisältää numeron 42
function sumWithoutBug(arr) {
if (arr.includes(42)) {
throw new Error('This number is not allowed!');
}
return arr.reduce((acc, val) => acc + val, 0);
}
test('sumWithoutBug should sum numbers', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (data) => {
sumWithoutBug(data);
})
);
});
Kun suoritat tämän testin, `fast-check` löytää melko varmasti epäonnistuvan tapauksen. Mutta se ei raportoi ensimmäistä löytämäänsä satunnaista taulukkoa, joka saattaa olla jotain kuten `[-1024, 500, 42, 987, -2000]`. Sellainen virheraportti ei ole kovin hyödyllinen. Sinun pitäisi tarkastaa se manuaalisesti löytääksesi ongelmallisen `42`:n.
Sen sijaan `fast-check`:in shrinker potkaisee käyntiin. Se näkee epäonnistumisen ja alkaa yksinkertaistaa syötettä:
- Voinko poistaa elementin? Kokeile `[500, 42, 987, -2000]`. Epäonnistuu edelleen. Hyvä.
- Voinko poistaa toisen? Kokeile `[42, 987, -2000]`. Epäonnistuu edelleen.
- ...ja niin edelleen, kunnes se ei voi poistaa enempää elementtejä ilman, että testi läpäisee.
- Se yrittää myös tehdä numeroista pienempiä. Voiko `42` olla `0`? Ei, testi läpäisee. Voiko se olla `41`? Testi läpäisee. Se rajaa sen alas.
Lopullinen virheraportti näyttää suunnilleen tältä:
Error: Property failed after 15 tests
{ seed: 12345678, path: "14", endOnFailure: true }
Counterexample: [[42]]
Shrunk 5 time(s)
Got error: This number is not allowed!
Se kertoo sinulle tarkan, minimaalisen syötteen, joka aiheutti epäonnistumisen: taulukko, joka sisältää vain numeron `[42]`. Tämä osoittaa sinut välittömästi bugin lähteeseen, säästäen sinulta valtavasti aikaa ja vaivaa virheenkorjauksessa.
Käytännön PBT-strategiat ja tosielämän esimerkit
PBT ei ole vain matemaattisia funktioita varten. Se on monipuolinen työkalu, jota voidaan soveltaa monille ohjelmistokehityksen osa-alueille.
Ominaisuus: Käänteisfunktiot
Jos sinulla on funktio, joka koodaa dataa ja toinen, joka purkaa sen, ne ovat toistensa käänteisiä. Hieno ominaisuus testata on, että koodatun arvon purkamisen pitäisi aina palauttaa alkuperäinen arvo.
// `encode` ja `decode` voisivat olla base64:lle, URI-komponenteille tai mukautetulle serialisoinnille
function encode(obj) { return JSON.stringify(obj); }
function decode(str) { return JSON.parse(str); }
test('decode(encode(x)) should be equal to x', () => {
// `fc.jsonValue()` generoi minkä tahansa kelvollisen JSON-arvon: merkkijonoja, numeroita, objekteja, taulukoita
fc.assert(
fc.property(fc.jsonValue(), (originalValue) => {
const encoded = encode(originalValue);
const decoded = decode(encoded);
expect(decoded).toEqual(originalValue);
})
);
});
Ominaisuus: Idempotenssi
Operaatio on idempotentti, jos sen useita kertoja soveltaminen vaikuttaa samalla tavalla kuin sen soveltaminen kerran. `f(f(x)) === f(x)`. Tämä on ratkaiseva ominaisuus esimerkiksi datan puhdistusfunktioille tai `DELETE`-päätepisteille REST API:ssa.
// Funktio, joka poistaa edeltävät/seuraavat välilyönnit ja kutistaa useita välilyöntejä
function normalizeWhitespace(text) {
return text.trim().replace(/\s+/g, ' ');
}
test('normalizeWhitespace should be idempotent', () => {
fc.assert(
fc.property(fc.string(), (originalString) => {
const once = normalizeWhitespace(originalString);
const twice = normalizeWhitespace(once);
expect(twice).toBe(once);
})
);
});
Ominaisuus: Tilallinen (Mallipohjainen) testaus
Tämä on edistyneempi, mutta uskomattoman tehokas tekniikka sisäisellä tilalla olevien järjestelmien testaamiseen, kuten käyttöliittymäkomponentti, ostoskori tai tilakone. Ajatuksena on luoda yksinkertainen ohjelmistomalli järjestelmästäsi ja sarja komentoja, jotka voidaan suorittaa sekä malliasi että todellista toteutusta vasten. Ominaisuus on, että mallin tila ja todellisen järjestelmän tila tulisi aina täsmätä.
`fast-check` tarjoaa `fc.commands` tähän tarkoitukseen. Mallinnetaan yksinkertainen laskuri:
// Todellinen toteutus
class Counter {
constructor() { this.count = 0; }
increment() { this.count++; }
decrement() { this.count--; }
get() { return this.count; }
}
// Komentoja fast-checkille
const incrementCmd = fc.command(
// check: funktio, joka tarkistaa, voidaanko komento suorittaa mallissa
(model) => true,
// run: funktio, joka suorittaa komennon sekä mallissa että todellisessa järjestelmässä
(model, real) => {
model.count++;
real.increment();
expect(real.get()).toBe(model.count);
}
);
const decrementCmd = fc.command(
(model) => true,
(model, real) => {
model.count--;
real.decrement();
expect(real.get()).toBe(model.count);
}
);
test('Counter should behave according to the model', () => {
fc.assert(
fc.property(fc.commands([incrementCmd, decrementCmd]), (cmds) => {
const model = { count: 0 };
const real = new Counter();
fc.modelRun(() => ({ model, real }), cmds);
})
);
});
Tässä testissä `fast-check` generoi satunnaisen sarjan `increment`- ja `decrement`-komentoja, suorittaa ne sekä yksinkertaista objektimalliamme että todellista `Counter`-luokkaa vasten ja varmistaa, että ne eivät koskaan poikkea toisistaan. Tämä voi paljastaa hienovaraisia bugeja monimutkaisessa tilallisessa logiikassa, joita olisi lähes mahdotonta löytää esimerkkiperustaisella testauksella.
Milloin EI pidä käyttää ominaisuusperustaista testausta
PBT on tehokas lisä testauspakettiisi, mutta se ei korvaa kaikkia muita testausmuotoja. Se ei ole hopealuoti.
Esimerkkiperustainen testaus on usein parempi, kun:
- Testataan tiettyjä, tunnettuja liiketoimintasääntöjä. Jos verolaskennan on tuotettava täsmälleen `$10.53` tietylle syötteelle, yksinkertainen esimerkkiperustainen testi on selkeämpi ja suoraviivaisempi. Tämä on regressiotesti tunnetulle vaatimukselle.
- "Ominaisuus" on vain "syöte X tuottaa tulosteen Y". Jos funktion käyttäytymisestä ei ole korkeamman tason, yleistettävää sääntöä, ominaisuusperustaisen testin pakottaminen voi olla monimutkaisempaa kuin sen arvo.
- Testataan käyttöliittymiä visuaalisen oikeellisuuden kannalta. Vaikka voit testata käyttöliittymäkomponentin tilalogiikkaa PBT:llä, tietyn visuaalisen asettelun tai tyylin tarkistaminen on parempi käsitellä snapshot-testauksella tai visuaalisilla regressiotyökaluilla.
Tehokkain strategia on hybridilähestymistapa. Käytä ominaisuusperustaisia testejä stressitestaamaan algoritmejasi, datan muunnoksiasi ja tilallista logiikkaasi mahdollisuuksien maailmaa vasten. Käytä perinteisiä esimerkkiperustaisia testejä tiettyjen, kriittisten liiketoimintavaatimusten kiinnittämiseen ja tunnettujen bugien regressioiden estämiseen.
Johtopäätös: Ajattele ominaisuuksissa, älä vain esimerkeissä
Ominaisuusperustainen testaus kannustaa syvälliseen muutokseen siinä, miten ajattelemme oikeellisuutta. Se pakottaa meidät vetäytymään yksittäisistä esimerkeistä ja harkitsemaan niitä perusperiaatteita ja sopimuksia, joita koodimme pitäisi noudattaa. Näin voimme:
- Paljastaa yllättäviä reunatapauksia, joita emme olisi koskaan ajatelleet kirjoittaa testejä varten.
- Saavuttaa paljon suuremman luottamuksen koodimme vankkuuteen.
- Kirjoittaa ilmeikkäämpiä testejä, jotka dokumentoivat järjestelmämme käyttäytymistä sen sijaan, että dokumentoisivat vain sen tulosteen muutamissa syötteissä.
- Vähentää dramaattisesti virheenkorjausaikaa shrinkingin voiman ansiosta.
Ominaisuusperustaisen testauksen käyttöönotto saattaa tuntua aluksi oudolta, mutta investointi on sen arvoinen. Aloita pienesti. Valitse koodikannastasi puhdas funktio – sellainen, joka käsittelee datan muuntamista tai monimutkaista laskentaa – ja yritä määritellä sille ominaisuus. Lisää yksi ominaisuusperustainen testi seuraavaan projektiisi. Kun todistat sen löytävän ensimmäisen ei-triviaalin bugin, olet vakuuttunut sen voimasta rakentaa parempaa ja luotettavampaa ohjelmistoa globaalille yleisölle.
Lisäresurssit
- fast-check virallinen dokumentaatio
- Understanding Property-Based Testing kirjoittanut Scott Wlaschin (klassinen, kieliriippumaton johdanto)